import {
  auth as authCore,
  constants,
  context,
  events,
  utils as utilsCore,
  configs,
  cache,
} from "@budibase/backend-core"
import {
  ConfigType,
  User,
  Ctx,
  LoginRequest,
  SSOUser,
  PasswordResetRequest,
  PasswordResetUpdateRequest,
  GoogleInnerConfig,
  DatasourceAuthCookie,
  LogoutResponse,
  UserCtx,
  SetInitInfoRequest,
  GetInitInfoResponse,
  PasswordResetResponse,
  PasswordResetUpdateResponse,
  SetInitInfoResponse,
  LoginResponse,
} from "@budibase/types"
import env from "../../../environment"
import { Next } from "koa"

import * as authSdk from "../../../sdk/auth"
import * as userSdk from "../../../sdk/users"

const { Cookie, Header } = constants
const { passport, ssoCallbackUrl, google, oidc } = authCore
const { setCookie, getCookie, clearCookie } = utilsCore

// LOGIN / LOGOUT

const normalizeEmail = (e: string) => (e || "").toLowerCase()
const failKey = (email: string) => `auth:login:fail:${normalizeEmail(email)}`
const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
const isLocked = async (email: string) => {
  return !!(await cache.get(lockKey(email)))
}

const handleLockoutResponse = (ctx: Ctx, email: string) => {
  ctx.set("X-Account-Locked", "1")
  ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
  console.log(
    `[auth] login blocked (post-failure) due to lock email=${normalizeEmail(email)}`
  )
  return ctx.throw(403, "Account temporarily locked. Try again later.")
}
const onFailed = async (email: string) => {
  if (!email) return
  const key = failKey(email)
  const currentAttempt = Number((await cache.get(key)) || 0) || 0
  const nextAttempt = currentAttempt + 1
  await cache.store(key, nextAttempt, env.LOGIN_LOCKOUT_SECONDS)
  console.log(
    `[auth] failed login email=${normalizeEmail(email)} count=${nextAttempt}`
  )
  if (nextAttempt >= env.LOGIN_MAX_FAILED_ATTEMPTS) {
    await cache.store(lockKey(email), "1", env.LOGIN_LOCKOUT_SECONDS)
    await cache.destroy(key)
    console.log(
      `[auth] account locked email=${normalizeEmail(email)} for ${env.LOGIN_LOCKOUT_SECONDS}s`
    )
  }
}
const clearFailureState = async (email: string) => {
  if (!email) return
  await cache.destroy(failKey(email))
  await cache.destroy(lockKey(email))
}

async function passportCallback(
  ctx: Ctx,
  user: User,
  err: any = null,
  info: { message: string } | null = null
) {
  if (err) {
    console.error("Authentication error", err)
    console.trace(err)
    return ctx.throw(403, info ? info : "Unauthorized")
  }
  if (!user) {
    console.error("Authentication error - no user provided")
    return ctx.throw(403, info ? info : "Unauthorized")
  }

  const loginResult = await authSdk.loginUser(user)

  // set a cookie for browser access
  setCookie(ctx, loginResult.token, Cookie.Auth, { sign: false })
  // set the token in a header as well for APIs
  ctx.set(Header.TOKEN, loginResult.token)

  // add session invalidation info to response headers for frontend to handle
  if (loginResult.invalidatedSessionCount > 0) {
    ctx.set(
      "X-Session-Invalidated-Count",
      loginResult.invalidatedSessionCount.toString()
    )
  }
}

export const login = async (
  ctx: Ctx<LoginRequest, LoginResponse>,
  next: Next
) => {
  const email = ctx.request.body.username

  const dbUser = await userSdk.db.getUserByEmail(email)
  if (dbUser && (await userSdk.db.isPreventPasswordActions(dbUser))) {
    console.log(
      `[auth] login prevented due to sso enforcement email=${normalizeEmail(email)}`
    )
    ctx.throw(403, "Invalid credentials")
  }

  return passport.authenticate(
    "local",
    async (err: any, user: User, info: any) => {
      if (err || !user) {
        if (dbUser) {
          await onFailed(email)
        }
        if (await isLocked(email)) {
          return handleLockoutResponse(ctx, email)
        }
        const reason =
          (info && info.message) || (err && err.message) || "unknown"
        console.log(
          `[auth] password auth failed email=${normalizeEmail(email)} reason=${reason}`
        )
        // delegate to shared passport failure handling to preserve specific messages (e.g. expired)
        return passportCallback(ctx, user as any, err, info)
      }

      await clearFailureState(email)
      console.log(
        `[auth] password auth success email=${normalizeEmail(user.email)}`
      )
      await passportCallback(ctx, user, err, info)
      await context.identity.doInUserContext(user, ctx, async () => {
        await events.auth.login("local", user.email)
      })
      ctx.body = {
        message: "Login successful",
        userId: user.userId,
      }
    }
  )(ctx, next)
}

export const logout = async (ctx: UserCtx<void, LogoutResponse>) => {
  if (ctx.user && ctx.user._id) {
    await authSdk.logout({ ctx, userId: ctx.user._id })
  }
  ctx.body = { message: "User logged out." }
}

// INIT

export const setInitInfo = (
  ctx: UserCtx<SetInitInfoRequest, SetInitInfoResponse>
) => {
  const initInfo = ctx.request.body
  setCookie(ctx, initInfo, Cookie.Init)
  ctx.body = {
    message: "Init info updated.",
  }
}

export const getInitInfo = (ctx: UserCtx<void, GetInitInfoResponse>) => {
  try {
    ctx.body = getCookie(ctx, Cookie.Init) || {}
  } catch (err) {
    clearCookie(ctx, Cookie.Init)
    ctx.body = {}
  }
}

// PASSWORD MANAGEMENT

/**
 * Reset the user password, used as part of a forgotten password flow.
 */
export const reset = async (
  ctx: Ctx<PasswordResetRequest, PasswordResetResponse>
) => {
  const { email } = ctx.request.body

  const lcEmail = (email || "").toLowerCase()
  const ip = (ctx.ip || "").toString()

  // rate limit keys
  const emailKey = `auth:pwdreset:email:${lcEmail}`
  const ipKey = `auth:pwdreset:ip:${ip}`

  const increment = async (key: string, windowSeconds: number) => {
    const currentAttempt = Number((await cache.get(key)) || 0) || 0
    const nextAttempt = currentAttempt + 1
    await cache.store(key, nextAttempt, windowSeconds)
    return nextAttempt
  }

  // apply per-email and per-ip rate limits
  const nextEmail = await increment(
    emailKey,
    env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS
  )
  const nextIp = await increment(
    ipKey,
    env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
  )

  const emailLimited = nextEmail > env.PASSWORD_RESET_RATE_EMAIL_LIMIT
  const ipLimited = nextIp > env.PASSWORD_RESET_RATE_IP_LIMIT

  if (emailLimited || ipLimited) {
    // surfaced for ui to display
    ctx.set(
      "X-RateLimit-Email-Limit",
      String(env.PASSWORD_RESET_RATE_EMAIL_LIMIT)
    )
    ctx.set(
      "X-RateLimit-Email-Remaining",
      String(Math.max(env.PASSWORD_RESET_RATE_EMAIL_LIMIT - nextEmail, 0))
    )
    ctx.set("X-RateLimit-IP-Limit", String(env.PASSWORD_RESET_RATE_IP_LIMIT))
    ctx.set(
      "X-RateLimit-IP-Remaining",
      String(Math.max(env.PASSWORD_RESET_RATE_IP_LIMIT - nextIp, 0))
    )
    // best-effort retry window
    const retryAfter = Math.max(
      env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS,
      env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
    )
    ctx.set("Retry-After", String(retryAfter))
    console.log(
      `[auth] password reset rate limited email=${lcEmail} ip=${ip} emailCount=${nextEmail} ipCount=${nextIp}`
    )
    return ctx.throw(429, "Too many password reset requests. Try again later.")
  }

  await authSdk.reset(email)

  ctx.body = {
    message: "Please check your email for a reset link.",
  }
}

/**
 * Perform the user password update if the provided reset code is valid.
 */
export const resetUpdate = async (
  ctx: Ctx<PasswordResetUpdateRequest, PasswordResetUpdateResponse>
) => {
  const { resetCode, password } = ctx.request.body
  try {
    await authSdk.resetUpdate(resetCode, password)
    ctx.body = {
      message: "password reset successfully.",
    }
  } catch (err: any) {
    console.warn(err)
    // hide any details of the error for security
    ctx.throw(400, err.message || "Cannot reset password.")
  }
}

// DATASOURCE

export const datasourcePreAuth = async (
  ctx: UserCtx<void, void>,
  next: Next
) => {
  const provider = ctx.params.provider
  const { middleware } = require(`@budibase/backend-core`)
  const handler = middleware.datasource[provider]

  setCookie(
    ctx,
    {
      provider,
      appId: ctx.query.appId,
    },
    Cookie.DatasourceAuth
  )

  return handler.preAuth(passport, ctx, next)
}

export const datasourceAuth = async (ctx: UserCtx<void, void>, next: Next) => {
  const authStateCookie = getCookie<DatasourceAuthCookie>(
    ctx,
    Cookie.DatasourceAuth
  )
  if (!authStateCookie) {
    throw new Error("Unable to retrieve datasource authentication cookie")
  }
  const provider = authStateCookie.provider
  const { middleware } = require(`@budibase/backend-core`)
  const handler = middleware.datasource[provider]
  return handler.postAuth(passport, ctx, next)
}

// GOOGLE SSO

export async function googleCallbackUrl(config?: GoogleInnerConfig) {
  return ssoCallbackUrl(ConfigType.GOOGLE, config)
}

/**
 * The initial call that google authentication makes to take you to the google login screen.
 * On a successful login, you will be redirected to the googleAuth callback route.
 */
export const googlePreAuth = async (ctx: Ctx<void, void>, next: Next) => {
  const config = await configs.getGoogleConfig()
  if (!config) {
    return ctx.throw(400, "Google config not found")
  }
  let callbackUrl = await googleCallbackUrl(config)
  const strategy = await google.strategyFactory(
    config,
    callbackUrl,
    userSdk.db.save
  )

  return passport.authenticate(strategy, {
    scope: ["profile", "email"],
    accessType: "offline",
    prompt: "consent",
  })(ctx, next)
}

export const googleCallback = async (ctx: Ctx<void, void>, next: Next) => {
  const config = await configs.getGoogleConfig()
  if (!config) {
    return ctx.throw(400, "Google config not found")
  }
  const callbackUrl = await googleCallbackUrl(config)
  const strategy = await google.strategyFactory(
    config,
    callbackUrl,
    userSdk.db.save
  )

  return passport.authenticate(
    strategy,
    {
      successRedirect: env.PASSPORT_GOOGLEAUTH_SUCCESS_REDIRECT,
      failureRedirect: env.PASSPORT_GOOGLEAUTH_FAILURE_REDIRECT,
    },
    async (err: any, user: SSOUser, info: any) => {
      await passportCallback(ctx, user, err, info)
      await context.identity.doInUserContext(user, ctx, async () => {
        await events.auth.login("google-internal", user.email)
      })
      ctx.redirect(env.PASSPORT_GOOGLEAUTH_SUCCESS_REDIRECT)
    }
  )(ctx, next)
}

// OIDC SSO

export async function oidcCallbackUrl() {
  return ssoCallbackUrl(ConfigType.OIDC)
}

export const oidcStrategyFactory = async (ctx: any) => {
  const config = await configs.getOIDCConfig()
  if (!config) {
    return ctx.throw(400, "OIDC config not found")
  }

  let callbackUrl = await oidcCallbackUrl()

  //Remote Config
  const enrichedConfig = await oidc.fetchStrategyConfig(config, callbackUrl)
  return oidc.strategyFactory(enrichedConfig, userSdk.db.save)
}

/**
 * The initial call that OIDC authentication makes to take you to the configured OIDC login screen.
 * On a successful login, you will be redirected to the oidcAuth callback route.
 */
export const oidcPreAuth = async (ctx: Ctx<void, void>, next: Next) => {
  const { configId } = ctx.params
  if (!configId) {
    ctx.throw(400, "OIDC config id is required")
  }
  const strategy = await oidcStrategyFactory(ctx)

  setCookie(ctx, configId, Cookie.OIDC_CONFIG)

  const config = await configs.getOIDCConfigById(configId)
  if (!config) {
    return ctx.throw(400, "OIDC config not found")
  }

  let authScopes =
    config.scopes?.length > 0
      ? config.scopes
      : ["profile", "email", "offline_access"]

  return passport.authenticate(strategy, {
    // required 'openid' scope is added by oidc strategy factory
    scope: authScopes,
  })(ctx, next)
}

export const oidcCallback = async (ctx: Ctx<void, void>, next: Next) => {
  const strategy = await oidcStrategyFactory(ctx)

  return passport.authenticate(
    strategy,
    {
      successRedirect: env.PASSPORT_OIDCAUTH_SUCCESS_REDIRECT,
      failureRedirect: env.PASSPORT_OIDCAUTH_FAILURE_REDIRECT,
    },
    async (err: any, user: SSOUser, info: any) => {
      await passportCallback(ctx, user, err, info)
      await context.identity.doInUserContext(user, ctx, async () => {
        await events.auth.login("oidc", user.email)
      })
      ctx.redirect(env.PASSPORT_OIDCAUTH_SUCCESS_REDIRECT)
    }
  )(ctx, next)
}
